NULL은 값이 아니라 알 수 없음이다
NULL은 값이 아니라 알 수 없음이다
NULL 비교에는 IS NULL을 사용한다. 일반 비교 결과는 true나 false가 아니라 unknown이 될 수 있으며 WHERE는 true인 행만 남긴다.
목차
- #문제가 되는 상황
- #NULL과 빈 문자열, 0은 다르다
- #SQL의 삼값 논리
- #NULL 비교에는 IS NULL을 쓴다
- #NOT과 NOT IN에서 생기는 함정
- #JOIN에서 NULL이 만들어지는 경우
- #COUNT, SUM, AVG가 NULL을 다루는 방식
- #COALESCE는 표시와 계산 의미를 바꾼다
- #UNIQUE 제약과 NULL
- #도메인 상태를 NULL 하나로 뭉치지 않는다
- #애플리케이션 타입과 API 계약
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
배송 완료 시각이 아직 없다는 이유로 0000-00-00이나 빈 문자열을 저장하고, 할인 금액을 알 수 없다는 이유로 0을 저장하면 서로 다른 의미가 섞인다. 0원 할인은 확인된 값이지만 NULL 할인은 아직 계산되지 않았을 수 있다.
SQL의 NULL은 일반 값 하나가 아니라 누락되었거나 알 수 없는 상태를 나타내는 표지다. 비교 결과도 true와 false만이 아니라 unknown이 될 수 있다. 이 차이를 모르면 column = NULL, NOT IN, LEFT JOIN, COUNT와 AVG에서 조용히 행이 빠지거나 잘못된 숫자가 나온다.
사용자·주문·할인 데이터는 NULL 동작을 설명하기 위한 가상 값이다. 실제 사용자 정보를 사용하지 않았다.
NULL과 빈 문자열, 0은 다르다
다음 값은 서로 다른 도메인 상태다.
| 저장 값 | 가능한 의미 |
|---|---|
nickname = NULL |
아직 설정하지 않았거나 알 수 없음 |
nickname = '' |
사용자가 명시적으로 빈 문자열을 입력함 |
discount = 0 |
할인이 없다고 확정됨 |
discount = NULL |
아직 할인 계산 전 또는 정보 없음 |
스키마는 의미에 맞게 NULL 가능성을 정한다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
subtotal_minor BIGINT NOT NULL,
discount_minor BIGINT NULL,
discount_calculated_at DATETIME NULL
);
NOT NULL DEFAULT 0을 습관적으로 붙이면 “계산 전” 상태를 표현할 수 없게 된다. 반대로 반드시 존재해야 하는 주문 통화까지 nullable로 만들면 모든 query와 코드에 불필요한 분기가 퍼진다.
SQL의 삼값 논리
NULL이 포함된 일반 비교는 unknown이 될 수 있다.
| 표현 | 결과 |
|---|---|
5 = 5 |
TRUE |
5 = 7 |
FALSE |
5 = NULL |
UNKNOWN |
NULL = NULL |
UNKNOWN |
WHERE는 결과가 TRUE인 행만 남긴다. FALSE뿐 아니라 UNKNOWN도 제거한다.
SELECT id, discount_minor
FROM orders
WHERE discount_minor > 0;
이 query에서 discount가 NULL인 행은 NULL > 0이 unknown이므로 결과에 포함되지 않는다.
AND와 OR도 unknown을 전파한다.
| A | B | A AND B |
A OR B |
|---|---|---|---|
| TRUE | UNKNOWN | UNKNOWN | TRUE |
| FALSE | UNKNOWN | FALSE | UNKNOWN |
| UNKNOWN | UNKNOWN | UNKNOWN | UNKNOWN |
조건을 논리식만 보고 단순 변환할 때 NULL이 가능한 컬럼인지 확인해야 한다.
NULL 비교에는 IS NULL을 쓴다
다음 조건은 참이 되지 않는다.
SELECT *
FROM users
WHERE deleted_at = NULL;
NULL 여부를 확인하는 전용 predicate를 사용한다.
SELECT *
FROM users
WHERE deleted_at IS NULL;
SELECT *
FROM users
WHERE deleted_at IS NOT NULL;
두 nullable 값이 같다고 볼 때 “둘 다 NULL이면 같음”까지 포함하려면 DB가 제공하는 null-safe equality 연산을 확인한다. MySQL의 <=>, 표준적인 IS NOT DISTINCT FROM 지원 여부처럼 DB별 문법이 다를 수 있다.
-- MySQL의 null-safe equality 예
SELECT *
FROM snapshots
WHERE previous_value <=> current_value;
애플리케이션에서 동적으로 column = ?에 null을 bind한다고 자동으로 IS NULL이 되는지 query builder 동작을 확인한다.
NOT과 NOT IN에서 생기는 함정
NOT (discount_minor > 0)이 NULL인 행을 포함한다고 생각하기 쉽지만, NOT UNKNOWN도 UNKNOWN이다.
-- 0 이하인 확정 값만 포함하며 NULL은 포함하지 않는다.
WHERE NOT (discount_minor > 0)
NULL까지 포함하려면 의도를 명시한다.
WHERE discount_minor <= 0
OR discount_minor IS NULL
NOT IN의 목록에 NULL이 있으면 더 큰 함정이 생긴다.
SELECT id
FROM users
WHERE id NOT IN (
SELECT banned_user_id
FROM bans
);
bans.banned_user_id에 NULL이 하나 있으면 각 id가 목록의 모든 값과 다르다고 true로 확정할 수 없어 결과가 비어 보일 수 있다. 관계 부재를 찾을 때는 NOT EXISTS가 안전하고 의도가 명확하다.
SELECT u.id
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM bans b
WHERE b.banned_user_id = u.id
);
JOIN에서 NULL이 만들어지는 경우
LEFT JOIN은 오른쪽에 일치 행이 없으면 오른쪽 컬럼을 NULL로 채운다. 원래 profiles.nickname이 NOT NULL이어도 join 결과에서는 nullable이다.
SELECT u.id, p.nickname
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id;
없는 profile을 찾을 때 nullable 업무 컬럼보다 오른쪽 non-null key를 검사한다.
WHERE p.user_id IS NULL
p.nickname IS NULL을 사용하면 “profile 없음”과 “profile은 있지만 nickname이 NULL”을 구분하지 못할 수 있다.
JOIN key 자체가 NULL이면 일반 equality로 서로 일치하지 않는다.
ON a.optional_code = b.optional_code
양쪽 코드가 모두 NULL이어도 하나의 같은 코드로 join되지 않는다. NULL을 “기타 그룹”처럼 동일 값으로 취급하고 싶다면 그 의미가 맞는지 검토한 뒤 명시적으로 처리한다.
COUNT, SUM, AVG가 NULL을 다루는 방식
집계 함수는 서로 다른 방식으로 NULL을 처리한다.
SELECT
COUNT(*) AS row_count,
COUNT(discount_minor) AS known_discount_count,
SUM(discount_minor) AS discount_sum,
AVG(discount_minor) AS known_discount_average
FROM orders;
COUNT(*)는 행 수를 센다.COUNT(column)은 NULL이 아닌 값만 센다.SUM과AVG는 NULL 값을 제외한다.- 입력이 전부 NULL이거나 행이 없으면 SUM 결과가 NULL일 수 있다.
다음 데이터에서 AVG는 (0 + 1000) / 2 = 500이다. NULL을 0으로 포함한 / 3이 아니다.
| discount_minor |
|---|
| 0 |
| 1000 |
| NULL |
“알 수 없는 할인은 평균에서 제외”가 맞는지, “미계산 주문 때문에 평균을 보여 주면 안 됨”이 맞는지 제품 의미를 정한다.
COALESCE는 표시와 계산 의미를 바꾼다
COALESCE는 첫 번째 NULL이 아닌 값을 반환한다.
SELECT COALESCE(nickname, '이름 없음') AS display_name
FROM profiles;
UI 표시 기본값에는 유용하다. 하지만 계산에서 NULL을 0으로 바꾸면 통계 의미가 달라진다.
AVG(COALESCE(discount_minor, 0))
이제 미계산 할인도 0원으로 평균에 들어간다. 그것이 도메인 규칙이 아니라면 잘못된 통계다.
predicate에서 컬럼을 COALESCE하면 index 사용에도 영향을 줄 수 있다.
WHERE COALESCE(deleted_at, '9999-12-31') > CURRENT_TIMESTAMP
명확한 NULL 분기와 적절한 index 또는 generated column을 검토한다. 편의를 위해 의미와 실행 계획을 동시에 숨기지 않는다.
UNIQUE 제약과 NULL
여러 DB에서는 UNIQUE 컬럼에 NULL을 여러 개 허용할 수 있다. NULL끼리 같다고 판단하지 않기 때문이다. DB와 index 종류에 따라 semantics가 다르므로 실제 엔진을 확인한다.
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY,
external_handle VARCHAR(80) NULL,
UNIQUE KEY uq_external_handle (external_handle)
);
handle이 있는 사용자끼리는 중복을 막고 미설정 NULL은 여러 행에 있을 수 있다. “NULL도 최대 하나만” 같은 규칙이 필요하다면 별도 check·generated key·partial index 또는 schema 모델링이 필요하다.
복합 unique와 NULL의 조합도 예상과 다를 수 있다.
UNIQUE (tenant_id, external_code, deleted_at)
soft delete 활성 행의 deleted_at = NULL 중복을 이것만으로 막을 수 있다고 가정하지 않는다.
도메인 상태를 NULL 하나로 뭉치지 않는다
NULL 하나가 다음 세 상태를 모두 뜻하면 query와 UI가 구분할 수 없다.
- 사용자가 아직 입력하지 않음
- 해당 항목이 업무상 적용되지 않음
- 외부 시스템 장애로 알 수 없음
필요하면 상태 컬럼과 값을 함께 모델링한다.
CREATE TABLE risk_assessments (
order_id BIGINT PRIMARY KEY,
status VARCHAR(20) NOT NULL,
score DECIMAL(5,2) NULL,
CHECK (
(status = 'completed' AND score IS NOT NULL)
OR
(status IN ('pending', 'not-applicable', 'failed') AND score IS NULL)
)
);
판별 상태가 있으면 “score NULL”의 이유를 알고 재시도·UI 표시를 다르게 할 수 있다.
애플리케이션 타입과 API 계약
DB nullable은 애플리케이션 타입에도 반영한다.
type Order = {
id: string;
discountMinor: number | null;
discountStatus: "pending" | "calculated" | "not-applicable";
};
TypeScript optional field?: number는 필드가 없을 수 있음을 뜻하고 field: number | null은 필드가 존재하되 null일 수 있음을 뜻한다. JSON API에서 누락과 null의 의미를 OpenAPI에 정확히 표현한다.
{
"discountMinor": null,
"discountStatus": "pending"
}
ORM이 SQL NULL을 언어의 undefined, null, zero value 중 무엇으로 바꾸는지 확인한다. 저장 시 undefined를 “변경하지 않음”, null을 “값 제거”로 해석하는 PATCH API도 명시적인 계약이 필요하다.
실전 점검 목록
- NULL, 빈 문자열, 0의 도메인 의미를 구분했는가?
= NULL대신IS NULL을 사용하는가?- NOT과 NOT IN에서 unknown이 생길 수 있는가?
- LEFT JOIN에서 오른쪽 non-null key로 관계 부재를 검사하는가?
- COUNT(column), SUM, AVG가 NULL을 제외하는 의미가 맞는가?
- COALESCE가 통계와 index 의미를 바꾸지 않는가?
- DB의 UNIQUE와 NULL semantics를 실제로 확인했는가?
- 미입력·해당 없음·실패를 상태 컬럼으로 분리할 필요가 있는가?
- API의 optional과 nullable을 구분했는가?
NULL 비교에는 IS NULL을 사용한다. 일반 비교 결과는 true나 false가 아니라 unknown이 될 수 있으며 WHERE는 true인 행만 남긴다.
결론
NULL은 빈 문자열이나 0 같은 값이 아니라 누락되었거나 알 수 없는 상태를 나타내며 SQL 비교를 UNKNOWN으로 만들 수 있다. IS NULL, NOT EXISTS, NULL을 제외하는 집계 semantics를 이해하고 COALESCE로 의미를 무심코 바꾸지 않는다. 도메인에 미입력·해당 없음·처리 실패가 따로 필요하다면 상태 컬럼으로 분리하고 애플리케이션과 API의 nullable 계약까지 일치시켜야 한다.